Skip to content

feat: default to HTTP/2 transport on Node#815

Merged
jrvb-rl merged 6 commits into
mainfrom
feat/http2-default
Jul 10, 2026
Merged

feat: default to HTTP/2 transport on Node#815
jrvb-rl merged 6 commits into
mainfrom
feat/http2-default

Conversation

@jrvb-rl

@jrvb-rl jrvb-rl commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Why HTTP/2 (performance motivation)

On Node.js the previous default transport (node-fetch over HTTP/1.1) opens a new TCP + TLS connection per concurrent request — HTTP/1.1 can't multiplex, so a burst of N in-flight requests means up to N handshakes and, once the socket pool is saturated, requests queue waiting for a free connection. That connection setup and queueing lands squarely in the tail latency.

The SDK's node:http2 transport multiplexes many concurrent requests as streams over a small shared pool of connections: one handshake amortized across many requests, no per-request connection setup, and no pool-exhaustion queueing. Runloop SDK traffic is bursty and highly concurrent (agents firing many devbox operations in parallel), which is exactly the workload HTTP/2 multiplexing is built for. Bulk transfers stay at parity because the H2 session's flow-control window is sized so large streams aren't RTT-bound.

End-to-end benchmark (dev)

Measured against dev from this branch (Node v24), same client config with only the http2 flag flipped. Indicative single-run results from a shared environment — compare like-for-like, not as absolute targets.

Request handling under high concurrency (primary)

The SDK's own load harness — loadtest/loadtest.ts (from #798) — fires many devboxes.create calls concurrently at a deliberately nonexistent blueprint (bp_nonexistent_loadtest_00000). Every request fails fast server-side (all HTTP 400, zero devboxes created), which isolates client + server request handling from actual provisioning — i.e. purely "can the stack absorb a burst of requests at once." Run here at 2,000 concurrent requests (reduced from the harness default; the sandbox FD limit is 4096), http2 set explicitly per pass:

Metric HTTP/1.1 HTTP/2 Improvement
Wall clock (2,000 reqs) 16.13 s 0.98 s ~16× faster
Throughput 124 req/s 2,037 req/s ~16×
p50 latency 6,447 ms 744 ms ~8.7×
p95 latency 11,952 ms 908 ms ~13×
p99 latency 16,087 ms 969 ms ~16.6×
Result 2000× 400 2000× 400

Under a 2,000-request burst, HTTP/1.1 (node-fetch) opens a socket per request and serializes behind connection setup + pool limits, so latency balloons into multi-second territory; HTTP/2 multiplexes the whole burst over a small connection pool and clears it in under a second. This is the workload the default flip is aimed at.

Concurrent lightweight reads (secondary)

A smaller micro-benchmark firing N concurrent devboxes.list({ limit: 1 }) reads (warmup excluded, 0 failures) — a lighter endpoint where server processing dominates the median, so the win shows in the tail:

Concurrency Transport req/s p50 p95 p99
40 (×5) HTTP/1.1 149.5 203 441 460
40 (×5) HTTP/2 179.4 202 220 228
100 (×3) HTTP/1.1 192.2 457 630 638
100 (×3) HTTP/2 209.8 429 478 480

Concurrent large downloads (throughput parity)

8 × 10 MB via downloadFile, md5-verified (exercises the H2 flow-control window):

Transport Effective throughput
HTTP/1.1 55.7 MB/s
HTTP/2 62.0 MB/s

Bulk throughput is at parity (slightly ahead here), with all payloads byte-identical — HTTP/2 is not a regression for large transfers.

Summary

Makes HTTP/2 the default transport on Node.js. The SDK's native node:http2 transport multiplexes many concurrent requests over a small shared pool of TLS connections instead of opening one connection per request. Previously HTTP/2 was opt-in (http2: true); this flips the default from falsetrue.

  • Web / Deno / Bun: unaffected — the platform fetch already speaks HTTP/2, and the H2 shim is a no-op there.
  • Custom fetch: always wins over http2.
  • Opt out: http2: false selects the HTTP/1.1 node-fetch transport.

Transport resolution

httpAgent only applies to the HTTP/1.1 path — the H2 pool manages its own connections. To avoid silently changing behavior for existing users who pass an httpAgent, resolution is:

http2 httpAgent Result
unset unset HTTP/2 (new default, shared pool)
unset set HTTP/1.1, one-time warning (honors the agent)
false any HTTP/1.1, silent
true any HTTP/2 (shared pool); warns once if httpAgent also set
H2FetchOptions any HTTP/2 with a dedicated, tuned pool
any + custom fetch custom fetch

No existing httpAgent user is silently moved off HTTP/1.1: they get a one-time notice and can drop httpAgent to adopt H2 or pass http2: false to silence it.

Shared connection pool

All clients using the default transport share a single HTTP/2 pool, so short-lived clients (e.g. one per request) don't each open — and leak — their own H2 sessions. Explicit H2FetchOptions still get a dedicated pool, since that's deliberate tuning.

Transport correctness

Making H2 the default surfaced two behaviors of the transport that only mattered once non-opt-in clients used it, both fixed here:

  • Response contract — the H2 fetch now returns a standard node-fetch Response, so .asResponse() satisfies instanceof Response like the HTTP/1.1 transport does. Streaming is preserved (the H2 body is consumed as a Node Readable).
  • Process lifetime — idle pooled H2 sessions are unref'd (and ref'd only while a request is in flight), so a short Node script or test process exits normally instead of hanging on open sockets.

Code organization

The transport-selection logic and its one-time warnings live in a handwritten helper, src/lib/http2-transport.ts. The generated src/index.ts keeps only a one-line call-site hook, so regeneration stays clean.

Docs

Rewrote the README HTTP/2 transport section for H2-as-default and the http2: false opt-out, and corrected stale copy that still described the previous undici-based transport (it's native node:http2).

Release

Ships as a minor version bump; http2: false is the documented opt-out.

Test plan

  • tsc --noEmit clean.
  • Unit coverage for transport resolution: default→H2 with a shared pool, http2: false opt-out, dedicated pool for H2FetchOptions, and both one-time warnings.
  • Full jest suite against the mock server: 880 passed, 6 skipped, including all tests/api-resources/** and the h2-transport suite; the process exits promptly.

🤖 Generated with Claude Code

Flip the `http2` client option default from `false` to `true`, so the SDK
uses its native `node:http2` multiplexing transport by default on Node.js
instead of one HTTP/1.1 connection per request. Web/Deno/Bun are unaffected
(platform fetch already speaks h2), and a user-supplied `fetch` still wins.

Opting out: pass `http2: false` for the HTTP/1.1 `node-fetch` transport. To
keep existing `httpAgent` users working, a bare `httpAgent` (with no explicit
`http2`) keeps the client on HTTP/1.1 and emits a one-time warning; pass
`http2: false` to select HTTP/1.1 explicitly and silence it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codeant-ai

codeant-ai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

CodeAnt AI is reviewing your PR.

@codeant-ai

codeant-ai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@codeant-ai codeant-ai Bot added the size:L This PR changes 100-499 lines, ignoring generated files label Jul 9, 2026
Comment thread src/index.ts Outdated
Comment thread src/index.ts Outdated
@codeant-ai

codeant-ai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

CodeAnt AI finished reviewing your PR.

Reflex and others added 2 commits July 9, 2026 02:51
…essions

Making HTTP/2 the default surfaced two behaviors of the H2 transport that
only mattered once non-opt-in clients used it:

- `createH2Fetch` returned a bespoke `H2Response`, so `.asResponse()` no longer
  satisfied `instanceof Response` — breaking the generated resource tests that
  assert the raw response is a node-fetch `Response`. Adapt the internal
  `H2Response` to a real node-fetch `Response` at the fetch boundary (streaming
  preserved: the web `ReadableStream` body is consumed as a Node `Readable`).
- Pooled H2 sessions kept the event loop alive, so a short script (and Jest)
  would hang on exit instead of terminating like the HTTP/1.1 transport. Unref
  idle sessions and ref them only while a request is in flight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… default pool

Addresses PR review:

- Move the HTTP/2 transport-selection logic and one-time warnings out of the
  generated `src/index.ts` into a handwritten `src/lib/http2-transport.ts`
  helper (`resolveHttp2Fetch`). The generated file keeps only a one-line
  call-site hook, following the existing `makeHttp2Fetch` / `H2FetchOptions`
  pattern, so regeneration stays clean.
- Share a single default HTTP/2 pool across all clients instead of building a
  new pool per `new Runloop()`. Short-lived clients no longer leak H2 sessions.
  Explicit `H2FetchOptions` still get a dedicated pool (intentional tuning).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codeant-ai

codeant-ai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

CodeAnt AI is running Incremental review

@codeant-ai

codeant-ai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@codeant-ai codeant-ai Bot added size:L This PR changes 100-499 lines, ignoring generated files and removed size:L This PR changes 100-499 lines, ignoring generated files labels Jul 9, 2026
@codeant-ai

codeant-ai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

CodeAnt AI Incremental review completed.

Comment thread src/lib/http2-transport.ts Outdated
Comment thread src/lib/http2-transport.ts
Comment thread src/index.ts Outdated
Comment thread README.md
Addresses PR review:

- Clarify the `src/lib/http2-transport.ts` header: explain that transport
  selection matters only on Node (its default `node-fetch` speaks HTTP/1.1),
  while browsers/Deno/Bun already negotiate HTTP/2 via the platform fetch.
- Clarify the `http2` option JSDoc: "Node.js only" distinguishes it from other
  JS runtimes, where the option has no effect.
- Remove stale undici references now that the H2 transport is native
  `node:http2`: the `makeHttp2Fetch` shim docs (Node = `node:http2`, arg is
  H2FetchOptions), the web/Deno no-op comments, the "Replaces undici" note, and
  the undici-based rationale in the README requirements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codeant-ai

codeant-ai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

CodeAnt AI is running Incremental review

@codeant-ai

codeant-ai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@codeant-ai codeant-ai Bot added size:L This PR changes 100-499 lines, ignoring generated files and removed size:L This PR changes 100-499 lines, ignoring generated files labels Jul 10, 2026
@codeant-ai

codeant-ai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

CodeAnt AI Incremental review completed.

Per review: no compatibility layer — just make the behavior and reasoning
clear. A Node `http.Agent` configures HTTP/1.1 socket pooling, which has no
equivalent in HTTP/2's multiplexed model (H2 knobs live on `H2FetchOptions`),
so `httpAgent` has no effect on the H2 transport. Spell this out in the README
HTTP/2 section and in the `resolveHttp2Fetch` doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@james-rl james-rl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me.

Some trivial nits:

  • in tests/index.test.ts:105 references a variable that doesn't exist any more
  • src/lib/h2-transport/index.ts:69 has status 101 in NULL_BODY_STATUSES but it's unreachable on HTTP/2
  • src/lib/h2-transport/index.ts:89 uses Object.defineProperty for url but omits writable: true

…or, stale comment)

Per review on #815:

- Drop `101` from NULL_BODY_STATUSES — Switching Protocols is an HTTP/1.1
  upgrade status and can't occur over HTTP/2, so it was unreachable.
- Add `writable: true` to the `Response.url` property descriptor.
- Fix a stale comment in tests/index.test.ts that referenced the pre-refactor
  `useHttp2` / `makeHttp2Fetch(...)` shape; it's now `resolveHttp2Fetch(options)`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jrvb-rl

jrvb-rl commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @james-rl! All three nits fixed in 31661f3:

  • tests/index.test.ts stale comment — updated to reference the current fetch: options.fetch ?? resolveHttp2Fetch(options) (was still describing the pre-refactor useHttp2 / makeHttp2Fetch(...) shape).
  • 101 in NULL_BODY_STATUSES — removed; Switching Protocols is an HTTP/1.1 upgrade status and can't occur over HTTP/2, so it was unreachable. Now {204, 205, 304}.
  • Response.url descriptor — added writable: true.

tsc + lint clean; h2-transport (80) and http2 unit tests still green.

@jrvb-rl
jrvb-rl merged commit 35dd305 into main Jul 10, 2026
8 checks passed
@jrvb-rl
jrvb-rl deleted the feat/http2-default branch July 10, 2026 21:29
@stainless-app stainless-app Bot mentioned this pull request Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants